/*
 * Copyright (c) 2016 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */

package com.google.samples.apps.iosched.sync.userdata.firebase;

import android.support.annotation.NonNull;
import android.support.annotation.VisibleForTesting;
import android.text.TextUtils;

import com.google.samples.apps.iosched.sync.userdata.UserAction;
import com.google.samples.apps.iosched.sync.userdata.util.UserData;
import com.google.samples.apps.iosched.util.FirebaseUtils;

import java.util.HashMap;
import java.util.List;
import java.util.Map;


/**
 * Helper class for managing the merge of local and remote user data. Processes the {@link
 * com.firebase.client.DataSnapshot} from Firebase and creates a {@link
 * com.google.samples.apps.iosched.sync.userdata.util.UserData} object. Also creates
 * a {@code UserData} object from data stored in the local DB. Creates a merged {@code UserData}
 * object representing the consensus user data, and uses that to update both Firebase and the local
 * DB.
 */
public class MergeHelper {
    /**
     * Holds user data retrieved from the local ContentProvider.
     */
    private final UserData mLocalUserData;

    /**
     * Holds user data retrieved from Firebase.
     */
    private final UserData mRemoteUserData;

    /**
     * Holds data generated by merging local and remote user data.
     */
    private final UserData mMergedUserData;

    /**
     * The Firebase user ID associated with the currently chosen account.
     */
    private String mUid;

    public MergeHelper(@NonNull UserData localUserData,
            @NonNull UserData remoteUserData,
            @NonNull UserData mergedUserData,
            @NonNull String uid) {
        mLocalUserData = checkNotNull(localUserData);
        mRemoteUserData = checkNotNull(remoteUserData);
        mMergedUserData = checkNotNull(mergedUserData);
        mUid = uid;
    }

    @VisibleForTesting
    UserData getLocalUserData() {
        return mLocalUserData;
    }

    @VisibleForTesting
    UserData getRemoteUserData() {
        return mRemoteUserData;
    }

    @VisibleForTesting
    UserData getMergedUserData() {
        return mMergedUserData;
    }

    /**
     * Sets the GCM key for merged user data. Picks the remote GCM key if it exists; otherwise,
     * picks the local GCM key.
     */
    public void mergeGCMKeys() {
        String remoteGcmKey = mRemoteUserData.getGcmKey();
        String localGcmKey = mLocalUserData.getGcmKey();
        mMergedUserData.setGcmKey(remoteGcmKey == null || remoteGcmKey.isEmpty() ? localGcmKey :
                remoteGcmKey);
    }

    /**
     * Processes changes in local user data which were triggered by a user action and which may
     * require a remote Firebase sync. We maintain a flag per data item (see {@link
     * com.google.samples.apps.iosched.provider.ScheduleContract}), and when an item changes, we
     * attempt to sync it.
     *
     * @param actions The user actions that require a remote sync.
     */
    public void mergeUnsyncedActions(final List<UserAction> actions) {
        mMergedUserData.updateVideoIds(mRemoteUserData);
        for (Map.Entry<String, UserData.StarredSession> entry :
                mRemoteUserData.getStarredSessions().entrySet()) {
            mMergedUserData.getStarredSessions().put(entry.getKey(), entry.getValue());
        }
        mMergedUserData.updateFeedbackSubmittedSessionIds(mRemoteUserData);

        // Update merged data with local data.
        for (final UserAction action : actions) {
            if (action.requiresSync) {
                if (UserAction.TYPE.ADD_STAR.equals(action.type) ||
                        UserAction.TYPE.REMOVE_STAR.equals(action.type)) {

                    // The merged user data so far reflects remote. Override remote wherever local
                    // data is more recent.
                    UserData.StarredSession session = mMergedUserData.getStarredSessions().get(
                            action.sessionId);

                    // Either remote doesn't have this session, or local is more recent than
                    // remote.
                    if (session == null || session.getTimestamp() < action.timestamp) {
                        mMergedUserData.getStarredSessions().put(action.sessionId,
                                new UserData.StarredSession(
                                        UserAction.TYPE.ADD_STAR.equals(action.type),
                                        action.timestamp));
                    }
                } else if (UserAction.TYPE.VIEW_VIDEO.equals(action.type)) {
                    mMergedUserData.addVideoId(action.videoId);
                } else if (UserAction.TYPE.SUBMIT_FEEDBACK.equals(action.type)) {
                    mMergedUserData.addFeedbackSubmittedSessionId(action.sessionId);
                }
            }
        }
    }

    /**
     * Builds and returns a Map that can be used when calling {@link com.firebase.client
     * .Firebase#updateChildren(Map)} to update Firebase with a single write.
     *
     * @return A Map where the keys are String paths relative to Firebase root, and the values are
     * the data that is written to those paths.
     */
    public Map<String, Object> getPendingFirebaseUpdatesMap() {
        Map<String, Object> pendingFirebaseUpdatesMap = new HashMap<>();
        pendingFirebaseUpdatesMap.put(FirebaseUtils.getGcmKeyChildPath(mUid),
                mMergedUserData.getGcmKey());
        for (String videoID : mMergedUserData.getViewedVideoIds()) {
            pendingFirebaseUpdatesMap.put(FirebaseUtils.getViewedVideoChildPath(mUid, videoID),
                    true);
        }

        for (final Map.Entry<String, UserData.StarredSession> entry :
                getPendingFirebaseSessionUpdates().entrySet()) {
            updateSessionInSchedule(pendingFirebaseUpdatesMap, mUid, entry.getKey(),
                    entry.getValue().isInSchedule());
            updateSessionTimestamp(pendingFirebaseUpdatesMap, mUid, entry.getKey(),
                    entry.getValue().getTimestamp());
        }

        for (String sessionID : mMergedUserData.getFeedbackSubmittedSessionIds()) {
            pendingFirebaseUpdatesMap
                    .put(FirebaseUtils.getFeedbackSubmittedSessionChildPath(mUid, sessionID), true);
        }
        pendingFirebaseUpdatesMap.put(FirebaseUtils.getLastActivityTimestampChildPath(mUid),
                System.currentTimeMillis());
        return pendingFirebaseUpdatesMap;
    }

    /**
     * Returns sessions whose data must be written to Firebase.
     */
    private Map<String, UserData.StarredSession> getPendingFirebaseSessionUpdates() {
        Map<String, UserData.StarredSession> sessions = new HashMap<>();
        for (final Map.Entry<String, UserData.StarredSession> entry :
                mMergedUserData.getStarredSessions().entrySet()) {
            String sessionId = entry.getKey();
            UserData.StarredSession starredSession = entry.getValue();
            if (writeSessionDataToFirebase(sessionId, starredSession)) {
                sessions.put(sessionId, starredSession);
            }
        }
        return sessions;
    }

    /**
     * Returns whether a session's data must be written to Firebase or not.
     *
     * @param sessionId The ID of the session that has been added to or removed from a user's
     *                  schedule.
     * @param starredSession The data associated with {@code sessionID}.
     */
    private boolean writeSessionDataToFirebase(String sessionId, UserData.StarredSession
            starredSession) {
        Map<String, UserData.StarredSession> remoteStarredSessions =
                mRemoteUserData.getStarredSessions();
        return (!remoteStarredSessions.containsKey(sessionId) ||
                remoteStarredSessions.get(sessionId) != starredSession);
    }

    /**
     * Updates {@code map} with the sessions that have been added or removed from a user's schedule.
     *
     * @param map        Used when calling {@link com.firebase.client.Firebase#updateChildren(Map)}
     *                   to update Firebase with a single write.
     * @param uid        The Firebase user id associated with the currently chosen account.
     * @param sessionId  The ID of the session that was added or removed from a user's schedule.
     * @param inSchedule Whether session is in schedule (true), or removed from schedule (false).
     *
     */
    private void updateSessionInSchedule(Map<String, Object> map, String uid, String sessionId,
            boolean inSchedule) {
        map.put(FirebaseUtils.getStarredSessionInScheduleChildPath(uid, sessionId), inSchedule);
    }

    /**
     * Updates the timestamp for a session that was added or removed from a user's schedule.
     *
     * @param map       Used when calling {@link com.firebase.client.Firebase#updateChildren(Map)}
     *                  to update Firebase with a single write.
     * @param uid       The Firebase user id associated with the currently chosen account.
     * @param sessionId The ID of the session that was added or removed from a user's schedule.
     * @param timestamp The time when the session was starred or unstarred. In milliseconds since
     *                  epoch.
     */
    private void updateSessionTimestamp(Map<String, Object> map, String uid, String sessionId,
            Long timestamp) {
        map.put(FirebaseUtils.getStarredSessionTimestampChildPath(uid, sessionId), timestamp);
    }

    /**
     * Tracks whether the local gcm key should be updated.
     *
     * @return True if the local gcm key should be updated, otherwise false.
     */
    protected boolean isLocalGcmKeyUpdateNeeded() {
        return !TextUtils.equals(mMergedUserData.getGcmKey(), mLocalUserData.getGcmKey());
    }

    /**
     * Throws an exception if {@code userData} is null. Otherwise returns {@code userData}.
     *
     * @param userData The {@link UserData} object that holds the user data.
     */
    private UserData checkNotNull(UserData userData) {
        if (userData != null) {
            return userData;
        } else {
            throw new IllegalArgumentException("userData must not be null");
        }
    }
}